Skip to content

fix(db): reuse ReadOptions in ColumnDbSnapshot to reduce GC pressure#10894

Merged
smartprogrammer93 merged 9 commits intomasterfrom
fix/reuse-readoptions-in-column-db-snapshot
Mar 22, 2026
Merged

fix(db): reuse ReadOptions in ColumnDbSnapshot to reduce GC pressure#10894
smartprogrammer93 merged 9 commits intomasterfrom
fix/reuse-readoptions-in-column-db-snapshot

Conversation

@smartprogrammer93
Copy link
Contributor

@smartprogrammer93 smartprogrammer93 commented Mar 20, 2026

Changes

  • Share 2 ReadOptions instances (normal + cache-miss) across all 7 column RocksDbReader instances in ColumnDbSnapshot, instead of creating 14 separate ones
  • Explicitly destroy native ReadOptions handles via rocksdb_readoptions_destroy + GC.SuppressFinalize in ColumnDbSnapshot.Dispose(), with double-dispose guard
  • Replace Dictionary<T, IReadOnlyKeyValueStore> with flat array indexed by enum ordinal via Convert.ToInt32 (safe for all enum underlying types)
  • Share a single Func<ReadOptions> delegate across all column readers instead of 7 separate closures
  • Cache column keys and max ordinal on parent ColumnsDb<T> with volatile fields for thread safety
  • Add a new RocksDbReader constructor that accepts pre-created ReadOptions for shared use

Root cause: ReadOptions in RocksDbSharp has a finalizer (~ReadOptions()) but does not implement IDisposable. Each ColumnDbSnapshot created 14 finalizable ReadOptions objects (2 per column x 7 columns), plus a Dictionary, 7 RocksDbReader instances, and 7 closure delegates. In FlatState block processing, thousands of snapshots per second produce tens of thousands of finalizable objects that survive Gen0, get promoted to Gen1/Gen2, and trigger expensive stop-the-world GC collections.

Profiling data (ContractCall_200 FlatState, 2000 block processing calls per round, 20 rounds):

Metric FlatState (before) FlatState (after) Trie
Gen0 collections/round 38 35 30
Gen1 collections/round 9 6.8 0
Gen2 collections/round 1.3 0.8 0
Total Gen1 (20 rounds) 174 135 2
Total Gen2 (20 rounds) 26 16 0
Alloc/round 580MB 520MB 479MB
FinalizationPending 4,402 20 90
Gen2 heap size 20MB 17MB 7MB

Remaining Gen1/Gen2 pressure is from per-scope FlatState infrastructure objects (FlatWorldStateScope, SnapshotBundle, ReadOnlySnapshotBundle, StateTree, etc.) that require deeper architectural changes to pool.

Types of changes

  • Optimization
  • Bugfix (a non-breaking change that fixes an issue)

Testing

Requires testing

  • Yes

If yes, did you write tests?

  • Yes
  • No

Notes on testing

Validated via ProfileRunner benchmark with per-round GC tracking. Existing tests should pass since the behavior is identical - same ReadOptions configuration, just shared instead of duplicated. The array-indexed lookup via Convert.ToInt32 is functionally equivalent to the dictionary lookup and safe for all enum underlying types.

Documentation

Requires documentation update

  • No

Requires explanation in Release Notes

  • Yes
  • No

@smartprogrammer93
Copy link
Contributor Author

@claude review

@claude
Copy link

claude bot commented Mar 20, 2026

Claude finished @smartprogrammer93's task in 2m 5s —— View job


Code Review

Good optimization overall — the profiling data clearly justifies the changes, and the approach of eagerly destroying native handles + suppressing finalizers is sound. Below are the issues I found.


Critical

1. Unsafe.As<T, int> is unsafe for non-int-backed enums [ColumnsDb.cs:209, 220, 233]

int ord = Unsafe.As<T, int>(ref k);

where T : struct, Enum does not constrain the underlying type to int. If a caller uses a byte-, short-, long-, or uint-backed enum, Unsafe.As<T, int> performs a 4-byte read from a potentially 1- or 2-byte value on the stack — a buffer overread that silently produces garbage ordinals. Currently FlatState columns are int-backed, but this is a landmine for future callers.

Use Convert.ToInt32(k) for correctness across all underlying types, or leverage the already-present FastEnumUtility dependency which handles this safely.

Fix this →


2. No double-dispose guard in ColumnDbSnapshot.Dispose() [ColumnsDb.cs:237-247]

public void Dispose()
{
    Native.Instance.rocksdb_readoptions_destroy(_sharedReadOptions.Handle);
    GC.SuppressFinalize(_sharedReadOptions);
    Native.Instance.rocksdb_readoptions_destroy(_sharedCacheMissReadOptions.Handle);
    GC.SuppressFinalize(_sharedCacheMissReadOptions);
    _snapshot.Dispose();
}

If Dispose() is called more than once (which is valid per IDisposable contract), rocksdb_readoptions_destroy runs on an already-freed native handle — a use-after-free. Add a _disposed flag or use Interlocked.Exchange.

Fix this →


Moderate

3. Data race on _cachedColumnKeys and _cachedMaxOrdinal [ColumnsDb.cs:202-213]

T[] keys = columnsDb._cachedColumnKeys ??= columnsDb._columnDbs.Keys.ToArray();
if (columnsDb._cachedMaxOrdinal < 0)
{
    ...
    columnsDb._cachedMaxOrdinal = max;
}

ColumnsDb<T> is long-lived and CreateSnapshot() can be called from multiple threads. The ??= and if < 0 / assign pattern are not atomic — both threads can race through them. The race is "benign" (both compute identical results), but without volatile or Interlocked, the JIT/CPU is permitted to cache the stale value. Use LazyInitializer.EnsureInitialized for the array, and a volatile field or Interlocked.CompareExchange for the int.


4. Missing regression test [AGENTS.md]

Per project guidelines: "When fixing a bug, always add a regression test." The PR notes no tests were written. Even a simple unit test verifying that ColumnDbSnapshot.Dispose() runs without throwing (idempotency) and that shared ReadOptions are not double-freed would cover the fix.


Minor

5. readOptionsFactory closure comment is slightly misleading [ColumnsDb.cs:193-199]

// Single shared delegate for GetViewBetween — avoids per-reader closure allocation
Func<ReadOptions> readOptionsFactory = () => { ... };

The delegate is one allocation (good), but each call to GetViewBetween still invokes readOptionsFactory() which creates a new ReadOptions with a finalizer. These per-call ReadOptions are not explicitly destroyed. This is pre-existing behavior, but worth noting since the PR's motivation is exactly finalizer pressure — GetViewBetween-created ReadOptions remain unfixed.

6. LINQ in constructor [ColumnsDb.cs:202]

columnsDb._columnDbs.Keys.ToArray()

The coding style says "No LINQ when a simple for/foreach works." This is a constructor path (not hot), but a simple manual loop into a pre-sized array is consistent with the codebase style.


Summary

The optimization is well-motivated and the profiling data is compelling. The two critical issues (unsafe enum cast, missing double-dispose guard) should be addressed before merge. The thread-safety concern on the parent-level cache is also worth fixing since ColumnsDb is a long-lived shared object.

@smartprogrammer93
Copy link
Contributor Author

@claude rereview

@claude
Copy link

claude bot commented Mar 20, 2026

Claude finished @smartprogrammer93's task in 1m 38s —— View job


Re-review

Both critical issues from the previous review have been addressed. Here's the full updated status.


Previously Critical — Now Fixed

1. ✅ Unsafe.As<T, int> replaced with Convert.ToInt32 [ColumnsDb.cs:222, 231, 242]

All three call sites now use Convert.ToInt32(...) — correct for all enum underlying types. No remaining Unsafe.As usage.

2. ✅ Double-dispose guard added [ColumnsDb.cs:247-248]

if (_disposed) return;
_disposed = true;

Clean and correct. Note: per coding-style, ObjectDisposedException.ThrowIf is the convention for methods called on a disposed object (e.g. GetColumn) — not for Dispose() itself, where silent idempotency is the correct contract. The current approach is fine.


Previously Moderate — Addressed with Acknowledged Trade-off

3. ⚠️ Benign data race on _cachedColumnKeys / _cachedMaxOrdinal [ColumnsDb.cs:207-224]

Both fields are now volatile. The in-code comment explicitly documents the race and the reasoning:

"The race is benign (both threads compute identical results) and volatile ensures visibility across cores."

This is acceptable. volatile guarantees that once any thread writes the value, subsequent reads by other threads will observe it. Two threads racing through the null / < 0 check will both compute identical results and write the same value — there is no inconsistency possible. LazyInitializer.EnsureInitialized would be more idiomatic, but the current approach is correct.


Previously Minor — Fixed

4. ✅ LINQ removed from constructor [ColumnsDb.cs:211-214]

Replaced with a manual foreach loop — consistent with the no-LINQ-when-foreach-works style rule.

5. ✅ Clarifying comment on readOptionsFactory [ColumnsDb.cs:193-196]

The comment now explicitly calls out that per-call ReadOptions in GetViewBetween is pre-existing behavior not addressed by this PR. Good.


Still Open

6. Missing regression test

Per project guidelines (AGENTS.md): "When fixing a bug, always add a regression test." A simple test verifying ColumnDbSnapshot.Dispose() is idempotent (double-dispose does not throw or crash) would satisfy this requirement and directly cover the double-free fix.


New Observation

7. GetColumn does not guard against post-dispose use [ColumnsDb.cs:240-243]

public IReadOnlyKeyValueStore GetColumn(T key)
{
    return _readers[Convert.ToInt32(key)];
}

After Dispose(), the native ReadOptions handles are freed, but GetColumn still returns a live RocksDbReader whose _options point to destroyed handles. Calling Get/GetSpan on the returned reader would be a use-after-free at the native layer. The coding style convention is ObjectDisposedException.ThrowIf(_disposed, this) at the top of GetColumn. This is a pre-existing gap (the original code had no disposal at all), but since this PR introduces _disposed, it's a natural place to add the guard.

Fix this →


Summary

The two blockers from the first review are resolved. The remaining items are:

  • Missing test (moderate — project requirement per AGENTS.md)
  • Post-dispose GetColumn guard (minor — pre-existing gap, natural to fix since _disposed is now in place)

The optimization itself is solid and the profiling data clearly justifies it. Good to merge once the test is added.

Copy link
Member

@LukaszRozmej LukaszRozmej left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use helper methods to provide better context - naming what you are doing, will make code more self-documenting

public void Dispose()
{
snapshot.Dispose();
if (_disposed) return;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't need to be threadsafe?

We could do - although would need to by byte not bool

Suggested change
if (_disposed) return;
bool disposed = Interlocked.Exchange(ref _disposed, true);
if (disposed) return;

public IReadOnlyKeyValueStore GetColumn(T key)
{
return _columnDbs[key];
return _readers[Convert.ToInt32(key)];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can be single line with =>

Comment on lines +184 to +186
_sharedReadOptions = new ReadOptions();
_sharedReadOptions.SetVerifyChecksums(columnsDb.VerifyChecksum);
_sharedReadOptions.SetSnapshot(snapshot);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Helper method

Comment on lines +188 to +191
_sharedCacheMissReadOptions = new ReadOptions();
_sharedCacheMissReadOptions.SetVerifyChecksums(columnsDb.VerifyChecksum);
_sharedCacheMissReadOptions.SetSnapshot(snapshot);
_sharedCacheMissReadOptions.SetFillCache(false);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Helper method

Comment on lines +207 to +216
T[]? keys = columnsDb._cachedColumnKeys;
if (keys is null)
{
IDictionary<T, ColumnDb> columnDbs = columnsDb._columnDbs;
keys = new T[columnDbs.Count];
int idx = 0;
foreach (T key in columnDbs.Keys)
keys[idx++] = key;
columnsDb._cachedColumnKeys = keys;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

helper method

Comment on lines +218 to +224
if (columnsDb._cachedMaxOrdinal < 0)
{
int max = 0;
for (int i = 0; i < keys.Length; i++)
max = Math.Max(max, Convert.ToInt32(keys[i]));
columnsDb._cachedMaxOrdinal = max;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

helper method

Comment on lines +227 to +237
_readers = new RocksDbReader[columnsDb._cachedMaxOrdinal + 1];
for (int i = 0; i < keys.Length; i++)
{
T k = keys[i];
_readers[Convert.ToInt32(k)] = new RocksDbReader(
columnsDb,
_sharedReadOptions,
_sharedCacheMissReadOptions,
readOptionsFactory,
columnFamily: columnsDb._columnDbs[k]._columnFamily);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Helper method

@smartprogrammer93
Copy link
Contributor Author

@claude review

@claude
Copy link

claude bot commented Mar 20, 2026

Claude finished @smartprogrammer93's task in 2m 2s —— View job


Re-review (Final)

All previous critical and moderate issues have been resolved. LukaszRozmej's review suggestions are also addressed. Here is the full updated status.


Previously Critical — Fixed ✅

  1. Unsafe.As<T, int> replaced — now uses EnumToInt(T value) => Convert.ToInt32(value) helper. Correct for all enum underlying types.
  2. Double-dispose guardInterlocked.Exchange(ref _disposed, 1) != 0 with private int _disposed. Clean and correct.

LukaszRozmej's Suggestions — All Addressed ✅

  • Constructor chaining in RocksDbReader — the factory constructor already chains to the full constructor via : this(mainDb, readOptionsFactory(), readOptionsFactory(), ...), then mutates _hintCacheMissOptions.SetFillCache(false) in the body. This is the correct pattern since the readonly field is set by the chain and the mutation follows.
  • Interlocked.Exchange dispose guard — implemented with int _disposed as required (bool not supported by Interlocked.Exchange; int is idiomatic).
  • Single-line GetColumnpublic IReadOnlyKeyValueStore GetColumn(T key) => _readers[EnumToInt(key)];
  • Helper methodsDestroyReadOptions, EnumToInt, and the static local functions CreateReadOptions, CreateKeyCache, GetCachedMaxOrdinal all extracted. ✅

Minor Finding

Missing private on fields in RocksDbReader.cs:25-26

readonly ReadOptions _options;
readonly ReadOptions _hintCacheMissOptions;

All other fields in the class (lines 20-23) have explicit private readonly. These two are missing the private modifier — they're implicitly private in C#, but the inconsistency with the rest of the class is worth fixing for style consistency.

Fix this →


Still Open

GetColumn post-dispose guard — After Dispose(), native ReadOptions handles are freed but GetColumn still returns readers whose _options/_hintCacheMissOptions point to destroyed handles. Per coding style, ObjectDisposedException.ThrowIf(_disposed != 0, this) should be added. This is a pre-existing gap now surfaced by the introduction of _disposed.

Fix this →

Missing regression test — Per AGENTS.md: "When fixing a bug, always add a regression test." A simple test verifying ColumnDbSnapshot.Dispose() is idempotent (no throw on double-dispose) would directly cover the double-free fix and satisfy the project guideline.


Summary

The optimization is well-implemented and the profiling data clearly justifies it. The PR has been approved by LukaszRozmej. The two open items (GetColumn ODE guard and regression test) are minor but worth addressing before merge given the project's guidelines. The private modifier omission on two fields in RocksDbReader.cs is a trivial style inconsistency.

smartprogrammer93 and others added 5 commits March 21, 2026 22:56
…zer pressure

ColumnDbSnapshot previously created 14 ReadOptions per snapshot (2 per column
× 7 columns). ReadOptions in RocksDbSharp has a finalizer but no IDisposable,
so these objects survive Gen0, get promoted to Gen1/Gen2, and cause expensive
GC pauses. In FlatState block processing, 2000+ snapshots per BDN round
produce 28,000+ finalizable objects, causing Gen1 (9/round) and Gen2 (1-2/round)
collections that create timing spikes.

Fix: Create 2 shared ReadOptions (normal + cache-miss) per ColumnDbSnapshot
and pass them to all RocksDbReader instances. Explicitly destroy the native
handles via rocksdb_readoptions_destroy + GC.SuppressFinalize in Dispose().

This reduces finalizable objects from 14 to 0 per snapshot scope.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…egate

ColumnDbSnapshot used Dictionary<T, IReadOnlyKeyValueStore> to map columns
to readers, plus 7 separate Func<ReadOptions> closure allocations. Replace
with a flat array indexed by enum ordinal and a single shared delegate.

This eliminates per-snapshot: 1 Dictionary + backing array + 6 extra delegate
allocations (from 7 down to 1 shared).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace Unsafe.As<T, int> with Convert.ToInt32 for safe enum-to-int
  conversion regardless of underlying type (critical: buffer overread)
- Add _disposed guard to Dispose() to prevent double-free on native
  ReadOptions handles (critical: use-after-free)
- Use volatile fields for _cachedColumnKeys/_cachedMaxOrdinal to ensure
  cross-thread visibility (moderate: data race)
- Replace LINQ ToArray() with manual loop (minor: style)
- Clarify comment on readOptionsFactory noting GetViewBetween still
  creates per-call ReadOptions (minor: documentation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@smartprogrammer93 smartprogrammer93 force-pushed the fix/reuse-readoptions-in-column-db-snapshot branch from ff0933e to 4c697c3 Compare March 21, 2026 19:58
…sion tests

Add ObjectDisposedException guard in ColumnDbSnapshot.GetColumn() to
prevent use-after-free on destroyed native ReadOptions handles. Add
explicit private modifier to RocksDbReader fields for style consistency.
Add regression tests for idempotent double-dispose and post-dispose
GetColumn behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@smartprogrammer93
Copy link
Contributor Author

@claude review

@claude
Copy link

claude bot commented Mar 21, 2026

Claude finished @smartprogrammer93's task in 1m 8s —— View job


Re-review (Final Pass)

  • Reading changed files
  • Analyzing code
  • Posting review

All previously raised issues have been resolved. Here is the complete status.


Previously Critical — Fixed ✅

1. Unsafe.As<T, int> replaced [ColumnsDb.cs:282]

private static int EnumToInt(T value) => Convert.ToInt32(value);

All three call sites use this helper. Correct for all enum underlying types.

2. Double-dispose guard [ColumnsDb.cs:266]

if (Interlocked.Exchange(ref _disposed, 1) != 0) return;

Clean and correct. int field required since Interlocked.Exchange does not support bool.


Previously Moderate — Fixed ✅

3. Thread safety on _cachedColumnKeys / _cachedMaxOrdinal [ColumnsDb.cs:23-24]

Both fields are volatile. The benign-race + visibility pattern is correctly documented in the inline comment. Acceptable.


Previously Minor — Fixed ✅

4. Missing private modifier on RocksDbReader fields [RocksDbReader.cs:25-26]

private readonly ReadOptions _options;
private readonly ReadOptions _hintCacheMissOptions;

Now matches the rest of the class.

5. Missing regression tests [ColumnsDbTests.cs:128-148]

Two new tests added:

  • Snapshot_DoubleDispose_DoesNotThrow — covers the double-free fix directly
  • Snapshot_GetColumn_AfterDispose_ThrowsObjectDisposedException — covers the ObjectDisposedException.ThrowIf guard

Satisfies the project's "always add a regression test" requirement from AGENTS.md.

6. GetColumn post-dispose guard [ColumnsDb.cs:260]

ObjectDisposedException.ThrowIf(_disposed != 0, this);

Guards against use-after-free on destroyed native ReadOptions handles. Correct.


Informational (Pre-existing, Not a Blocker)

GetViewBetween still creates one ReadOptions per call via _readOptionsFactory() — each has a finalizer and is never explicitly destroyed. This is the same behavior as before this PR, and it's explicitly acknowledged in the constructor comment (line 190-192). Not a new issue, and out of scope for this optimization.


Summary

All critical, moderate, and minor issues from previous reviews are resolved. Tests cover both the double-dispose fix and the dispose guard on GetColumn. The PR is well-implemented and ready to merge.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes RocksDB column snapshots in Nethermind.Db.Rocks to reduce allocation/finalizer pressure during high-frequency snapshot creation (notably in FlatState processing).

Changes:

  • Reuse a shared pair of ReadOptions across all column readers within ColumnsDb<T>.ColumnDbSnapshot, and explicitly destroy their native handles on dispose.
  • Replace per-snapshot Dictionary<TKey, IReadOnlyKeyValueStore> with a flat array indexed by enum ordinal, and cache column keys/max ordinal on the parent ColumnsDb<T>.
  • Add snapshot lifecycle tests (double-dispose + GetColumn-after-dispose) and a new RocksDbReader constructor that accepts pre-created ReadOptions.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
src/Nethermind/Nethermind.Db.Test/ColumnsDbTests.cs Adds tests covering snapshot dispose behavior (idempotent dispose + disposed-guard).
src/Nethermind/Nethermind.Db.Rocks/RocksDbReader.cs Adds a constructor overload to allow shared ReadOptions injection for snapshot readers.
src/Nethermind/Nethermind.Db.Rocks/ColumnsDb.cs Implements shared ReadOptions, explicit native destroy, cached key metadata, and array-based column lookup in snapshots.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +276 to +279
private static void DestroyReadOptions(ReadOptions options)
{
Native.Instance.rocksdb_readoptions_destroy(options.Handle);
GC.SuppressFinalize(options);
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DestroyReadOptions calls Native.Instance.rocksdb_readoptions_destroy(...) directly, bypassing the _rocksDbNative instance used throughout DbOnTheRocks/ColumnsDb for native interop (and which can be injected). For consistency and to preserve the ability to use a non-default Native implementation, consider using the owning DB's _rocksDbNative (e.g., capture it in ColumnDbSnapshot and use that here).

Copilot uses AI. Check for mistakes.
{
return _columnDbs[key];
ObjectDisposedException.ThrowIf(_disposed != 0, this);
return _readers[EnumToInt(key)];
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetColumn now indexes directly into _readers without any bounds / null checks. If a caller passes an enum value that wasn't part of the configured columns (or the enum is sparse), this will either return null (leading to later NREs) or throw IndexOutOfRangeException, whereas the previous dictionary implementation would reliably throw a key-missing exception. Consider validating the computed ordinal and that the slot is populated, and throw a consistent exception (e.g., KeyNotFoundException/ArgumentOutOfRangeException) when the column isn't present.

Suggested change
return _readers[EnumToInt(key)];
int ordinal = EnumToInt(key);
if ((uint)ordinal >= (uint)_readers.Length || _readers[ordinal] is null)
{
throw new KeyNotFoundException($"Column '{key}' is not configured.");
}
return _readers[ordinal];

Copilot uses AI. Check for mistakes.
GC.SuppressFinalize(options);
}

private static int EnumToInt(T value) => Convert.ToInt32(value);
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EnumToInt uses Convert.ToInt32(value) on a generic enum T. This binds to the Convert.ToInt32(object) overload, which boxes the enum value and can introduce per-call allocations in a hot path (constructor loop + every GetColumn). Since the goal of this PR is to reduce GC pressure, consider switching to a non-boxing conversion (e.g., a cached converter per T based on the enum underlying type, or constrain/assume int underlying for column enums and use a direct cast/Unsafe conversion).

Suggested change
private static int EnumToInt(T value) => Convert.ToInt32(value);
private static int EnumToInt(T value) => value.ToInt32();

Copilot uses AI. Check for mistakes.
- Use captured _rocksDbNative instead of Native.Instance in DestroyReadOptions for consistency with injectable native interop
- Add bounds/null check in GetColumn to throw KeyNotFoundException (matching prior Dictionary behavior) instead of IndexOutOfRangeException/NRE
- Replace boxing Convert.ToInt32(object) with Unsafe.As<T, int> in EnumToInt to avoid per-call allocation on the read path

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@smartprogrammer93
Copy link
Contributor Author

@claude review

@claude
Copy link

claude bot commented Mar 21, 2026

Claude finished @smartprogrammer93's task in 1m 57s —— View job


Re-review

  • Reading changed files
  • Analyzing code and Copilot feedback
  • Posting review

Previously Resolved — Status Update

All items from the three previous review rounds remain addressed:

  • ✅ Double-dispose guard: Interlocked.Exchange(ref _disposed, 1) != 0
  • GetColumn ODE guard: ObjectDisposedException.ThrowIf(_disposed != 0, this)
  • ✅ Bounds check in GetColumn (Copilot concern addressed): (uint)ordinal >= (uint)_readers.Length || _readers[ordinal] is null throws KeyNotFoundException
  • _rocksDbNative captured from columnsDb (Copilot concern addressed): _rocksDbNative = columnsDb._rocksDbNative — no more Native.Instance direct call in DestroyReadOptions
  • ✅ Regression tests: Snapshot_DoubleDispose_DoesNotThrow and Snapshot_GetColumn_AfterDispose_ThrowsObjectDisposedException

Regression: Unsafe.As<T, int> is back [ColumnsDb.cs:292-294]

// Safety: all column enums in this codebase use int as underlying type.
// Unsafe.As avoids the boxing that Convert.ToInt32(object) would cause.
private static int EnumToInt(T value) => Unsafe.As<T, int>(ref value);

This was the critical issue from review 1, fixed in review 2 with Convert.ToInt32, and is now reverted. The comment acknowledges the trade-off correctly — Convert.ToInt32 calls the Convert.ToInt32(object) overload when T is a generic enum, which boxes. Unsafe.As<T, int> avoids that allocation.

The boxing concern is valid in a hot path, but Unsafe.As<T, int> on a 1- or 2-byte enum is a buffer overread. A safe non-boxing option that doesn't require a constraint on the underlying type:

private static int EnumToInt(T value)
{
    // JIT eliminates the branches at instantiation time — zero cost at runtime.
    if (sizeof(T) == sizeof(int)) return Unsafe.As<T, int>(ref value);
    if (sizeof(T) == sizeof(byte)) return Unsafe.As<T, byte>(ref value);
    if (sizeof(T) == sizeof(short)) return Unsafe.As<T, short>(ref value);
    return Convert.ToInt32(value); // fallback for long-backed (extremely rare)
}

Alternatively, add a Debug.Assert or a static check at ColumnDbSnapshot construction time to catch non-int enums early, which preserves the current Unsafe.As with an explicit safety net:

// In ColumnDbSnapshot constructor:
System.Diagnostics.Debug.Assert(Unsafe.SizeOf<T>() == sizeof(int),
    $"ColumnDbSnapshot requires an int-backed enum; {typeof(T).Name} is not.");

Either approach is acceptable. The current code is correct today but is a silent landmine for any future byte- or short-backed column enum.

Fix this →


Minor: Unnecessary volatile write on every snapshot [ColumnsDb.cs:197]

T[] keys = CreateKeyCache(columnsDb);
columnsDb._cachedMaxOrdinal = GetCachedMaxOrdinal(columnsDb, keys);  // ← always writes

GetCachedMaxOrdinal returns early if _cachedMaxOrdinal >= 0, but the result is always written back on line 197 — even when the value was already cached. A volatile write on every CreateSnapshot() call defeats the purpose of the cache and adds unnecessary memory fence overhead. Should be:

if (columnsDb._cachedMaxOrdinal < 0)
    columnsDb._cachedMaxOrdinal = GetCachedMaxOrdinal(columnsDb, keys);

Or move the write inside GetCachedMaxOrdinal.

Fix this →


Summary

The Copilot comments about Native.Instance and bounds checking are addressed. Two remaining items:

  • Unsafe.As<T, int> regression — was previously critical, now has a justification comment but remains fragile. Recommend adding a Debug.Assert or size-dispatched helper.
  • Unconditional volatile write on _cachedMaxOrdinal every snapshot — minor but directly contradicts the optimization goal.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +292 to +294
// Safety: all column enums in this codebase use int as underlying type.
// Unsafe.As avoids the boxing that Convert.ToInt32(object) would cause.
private static int EnumToInt(T value) => Unsafe.As<T, int>(ref value);
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EnumToInt uses Unsafe.As<T,int> which is only correct when the enum underlying type is int. ColumnsDb<T> is generic over any enum, so if a caller ever uses a non-int underlying type (or negative/large values), this can produce incorrect ordinals and lead to out-of-range writes in CreateReaders() (or huge array allocations). Consider switching to a conversion that is correct for all underlying enum types (e.g., based on Unsafe.SizeOf<T>() / Enum.GetUnderlyingType with non-boxing casts) and/or validating the configured keys up front before building the reader array.

Suggested change
// Safety: all column enums in this codebase use int as underlying type.
// Unsafe.As avoids the boxing that Convert.ToInt32(object) would cause.
private static int EnumToInt(T value) => Unsafe.As<T, int>(ref value);
// Convert enum value to int in a way that is safe for all underlying enum types.
private static int EnumToInt(T value) => Convert.ToInt32(value);

Copilot uses AI. Check for mistakes.
- Replace Unsafe.As<T, int> with size-dispatched helper that handles
  byte, short, and int-backed enums safely (JIT eliminates dead branches)
- Move _cachedMaxOrdinal volatile write inside GetCachedMaxOrdinal so it
  only writes when not yet cached, avoiding unnecessary memory fence on
  every CreateSnapshot call

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@smartprogrammer93
Copy link
Contributor Author

@claude review

@claude
Copy link

claude bot commented Mar 21, 2026

Claude finished @smartprogrammer93's task in 1m 24s —— View job


Re-review (Final Pass)

  • Reading changed files
  • Analyzing code
  • Posting review

All issues from every previous review round have been addressed. Here is the complete status.


Previously Critical — Fixed ✅

1. Unsafe.As<T, int> → size-dispatched helper [ColumnsDb.cs:294-300]

private static int EnumToInt(T value)
{
    if (Unsafe.SizeOf<T>() == sizeof(int)) return Unsafe.As<T, int>(ref value);
    if (Unsafe.SizeOf<T>() == sizeof(byte)) return Unsafe.As<T, byte>(ref value);
    if (Unsafe.SizeOf<T>() == sizeof(short)) return Unsafe.As<T, short>(ref value);
    return Convert.ToInt32(value); // fallback for long-backed enums
}

Correct for all enum underlying types. The JIT eliminates dead branches per instantiation — zero overhead at runtime.

2. Double-dispose guard [ColumnsDb.cs:276]

if (Interlocked.Exchange(ref _disposed, 1) != 0) return;

Clean and correct.


Previously Moderate — Fixed ✅

3. _cachedMaxOrdinal unconditional volatile write [ColumnsDb.cs:197, 229-240]

The write is now inside GetCachedMaxOrdinal, guarded by if (columnsDb._cachedMaxOrdinal >= 0) return. No longer writes on every CreateSnapshot() call.

4. Thread-safety on cached fields [ColumnsDb.cs:24-25]

Both _cachedColumnKeys and _cachedMaxOrdinal are volatile. Benign race is documented in comments. Correct.


Previously Minor — Fixed ✅

5. _rocksDbNative captured from parent [ColumnsDb.cs:173, 183, 288]

_rocksDbNative = columnsDb._rocksDbNative and used in DestroyReadOptions — no more Native.Instance call there.

6. Bounds check in GetColumn [ColumnsDb.cs:266-268]

if ((uint)ordinal >= (uint)_readers.Length || _readers[ordinal] is null)
    throw new KeyNotFoundException(...);

Efficient unsigned comparison. Consistent exception type.

7. GetColumn ODE guard [ColumnsDb.cs:263]

ObjectDisposedException.ThrowIf(_disposed != 0, this);

Correct per coding-style convention.

8. private readonly on RocksDbReader fields [RocksDbReader.cs:25-26]

_options and _hintCacheMissOptions now have explicit private readonly — consistent with the rest of the class.

9. Regression tests [ColumnsDbTests.cs:128-148]

  • Snapshot_DoubleDispose_DoesNotThrow — covers the double-free fix directly
  • Snapshot_GetColumn_AfterDispose_ThrowsObjectDisposedException — covers the ODE guard

Both tests satisfy the project's "always add a regression test" requirement.


Informational (Pre-existing, Not a Blocker)

GetViewBetween in RocksDbReader still uses Native.Instance directly for bound-setting calls (lines 126, 130) and creates a new ReadOptions per call without explicit destruction. Both are pre-existing behaviors unchanged by this PR and acknowledged in the constructor comment.


Summary

All critical, moderate, and minor issues across all four previous review rounds are resolved. The optimization is well-implemented, the profiling data clearly justifies it, and the code is correct. Ready to merge.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

_hintCacheMissOptions = readOptionsFactory();
_hintCacheMissOptions.SetFillCache(false);
_options = options;
_hintCacheMissOptions = hintCacheMissOptions;
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new constructor that accepts pre-created ReadOptions doesn’t enforce the invariant that hintCacheMissOptions should have FillCache=false (the factory-based ctor always sets this). Since this ctor is public, it’s easy for a future caller to accidentally pass an options instance with FillCache=true, changing cache behavior. Consider setting hintCacheMissOptions.SetFillCache(false) inside this ctor (or validating/asserting it) so both constructors preserve the same semantics by default.

Suggested change
_hintCacheMissOptions = hintCacheMissOptions;
_hintCacheMissOptions = hintCacheMissOptions;
_hintCacheMissOptions.SetFillCache(false);

Copilot uses AI. Check for mistakes.
Comment on lines +292 to 300
// Non-boxing enum-to-int conversion. JIT eliminates dead branches at
// instantiation time, so this is zero-cost for any underlying type.
private static int EnumToInt(T value)
{
if (Unsafe.SizeOf<T>() == sizeof(int)) return Unsafe.As<T, int>(ref value);
if (Unsafe.SizeOf<T>() == sizeof(byte)) return Unsafe.As<T, byte>(ref value);
if (Unsafe.SizeOf<T>() == sizeof(short)) return Unsafe.As<T, short>(ref value);
return Convert.ToInt32(value); // fallback for long-backed enums
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment above EnumToInt says “Non-boxing enum-to-int conversion”, but the Convert.ToInt32(value) fallback will box for most non-(byte/short/int)-sized enum underlying types due to overload resolution in generics. Consider tightening the comment (or adding explicit long/uint/ushort unsafe branches if truly aiming for non-boxing).

Copilot uses AI. Check for mistakes.
Ensures cross-platform visibility of the dispose flag, matching
the Interlocked.Exchange write in Dispose().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@smartprogrammer93 smartprogrammer93 merged commit e1cc5d8 into master Mar 22, 2026
420 checks passed
@smartprogrammer93 smartprogrammer93 deleted the fix/reuse-readoptions-in-column-db-snapshot branch March 22, 2026 12:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants